Skip to content

fix: send paywall_open to a paywall page reloaded after its web process dies - #545

Merged
yusuftor merged 6 commits into
developfrom
christo/paywall-open-after-reload
Oct 13, 2026
Merged

yusuftor merged 6 commits into
developfrom
christo/paywall-open-after-reload

Conversation

@chroxify

@chroxify chroxify commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

The bug

Paywalls that gate their entrance on paywall_open go blank after iOS kills the WKWebView content process. Every framework paywall gates this way, because the SDK preloads paywalls hidden and a mount-timed animation would finish before anyone sees it.

App 1022's event log shows paywallWebviewLoad_processTerminated for all 11 of its paywall web views when the app returned from the background, followed about a second later by paywallWebviewLoad_complete. The page reloaded, but it never left its pre-open state and showed only its background colour.

There are two paths, and both lose paywall_open:

  1. Killed while presented. webViewWebContentProcessDidTerminate calls reload(). The new document pings and gets templates, but paywall_open is only ever sent from trackOpen(), which runs once per presentation. The reloaded page is never told it is on screen.
  2. Killed while preloaded. The SDK sets didFailToLoad and calls loadWebView() at presentation, and then trackOpen() runs. Readiness was keyed on paywall.paywalljsVersion == nil, but that value survives from the dead page. The SDK treats the still-loading document as ready and evaluates accept64 into it, so the message is lost.

The fix

PaywallMessageHandler now tracks readiness per document instead of per paywall:

  • documentWillLoad() marks the page not ready. It runs when a new page commits, which covers the reload after process termination, the refresh button, and the initial load. loadWebView() also calls it as soon as it starts, so a paywall loading over a page that is still on screen doesn't send paywall_open to the old page. A load that fails before it commits leaves the old page, and whether it was ready, as it was.
  • onReady (the page's ping) marks the page ready. The existing queue drain in didLoadWebView delivers the queued paywall_open after templates.
  • paywall_open and paywall_close set or clear the open state, and queue while the page is not ready.

Only the message to the page is repeated. The analytics paywall_open event, didPresentPaywall, and storage.trackPaywallOpen() still fire once per presentation.

Why not a new event, or a fix in the framework

  • A new paywall_reload event would need every paywall to handle a second signal for "you are on screen". The reloaded document is a fresh page that has never been told it is open, so telling it is exactly the meaning of paywall_open. The framework runtime already treats a repeated paywall_open as idempotent.
  • Detecting the reload in the page, for example by checking navigation.type === "reload", is unreliable. It also cannot tell a reload under a presented paywall from one under a hidden preload, where revealing would play the entrance unseen.

Tests

Six new PaywallMessageHandlerTests cover these cases:

  • Open after ready passes straight through.
  • Open before ready waits for the ping.
  • A reload while open re-sends paywall_open exactly once.
  • A reload while hidden sends nothing.
  • A reload after close sends nothing.
  • A page that dies before it is ready still gets paywall_open only once.

PaywallMessageHandlerTests, SWWebViewLoadingHandlerTests, SWWebViewLogicTests, RawWebMessageHandlerTests and PresentationIdTests pass on an iOS 27.0 simulator.

End-to-end on a simulator

I ran the Basic example with devServer = .default against superwall dev serving the with-motion example, which gates its entrance on paywall_open. The paywall was presented, then the web content process was killed with kill -9. I ran this once with develop and once with this branch, decoding the SDK's debug log for the messages posted to the page.

develop this branch
Paywall on first open blank renders
Kill while in the foreground stays blank, no paywall_open to the new page renders, paywall_open sent once after the new page's ping
Kill while in the background, then return not run renders, same process resumed, paywall_open sent once
Analytics paywall_open per presentation 1 1
Paywall analytics event counts same in both same in both

The first row is a second bug this fixes. Dev mode gives the paywall a non-nil empty paywalljsVersion, so the old readiness check sent paywall_open before the local page was ready. Gated paywalls served by superwall dev never opened. The changelog has a line for it.

Not covered end to end: a process killed while the paywall is preloaded but hidden, because dev mode disables preloading. The unit tests cover that path. Not verified on a physical device.

Android

Not checked. Android forwards render-process crashes through onRenderCrashed, but I did not trace what its handler does. Android sends paywall_open from trackOpen(), including when the app returns from the background. Whether a reloaded page there gets it is still an open question.

🤖 Generated with Claude Code

@greptile-apps

greptile-apps Bot commented Oct 9, 2026

Copy link
Copy Markdown

This PR does not match any of the 1 configured review trigger rule.

@maple-review-bot

maple-review-bot Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

Maple review

🟢 Confidence 9/10 · safe to merge
The lifecycle changes are contained, and regression tests cover both queued delivery and replay suppression.
quality 100/100 · no findings · tests covered · risk medium

Tracks web-document readiness separately from paywall state and replays paywall_open after reloads while presented. The change is safe to merge; six regression tests cover ready, loading, hidden, closed, and repeated-load cases.

What was checked
  • Readiness resets cover initial loading, refresh, and web-process termination.
  • Replay bypasses trackOpen(), preserving presentation analytics and delegate callback counts.
  • trackClose() clears replay state; tests cover hidden and closed reloads.

169c8a5 · Updated on every push. Reply "won't fix" to dismiss a finding, or mention @maple-review-bot to ask about one.

@chroxify
chroxify requested a review from yusuftor October 9, 2026 13:34

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ No critical issues — one suggestion inline to close the remaining reload gaps.

Reviewed changes

Reviewed the full diff: per-document readiness in PaywallMessageHandler, its call sites, the tests and the changelog entry.

  • Readiness moved off paywall.paywalljsVersion — isDocumentReady is reset by documentWillLoad() and set by onReady, so a value left over from a dead page no longer counts as ready.
  • Open state tracked in the handler — isPaywallOpen follows paywall_open/paywall_close, and documentWillLoad() queues paywall_open again for the next document while the paywall is open.
  • Hooks at every SDK-initiated load — loadWebView(), the refresh button's reloadWebView() and webViewWebContentProcessDidTerminate all call documentWillLoad().
  • Analytics unchanged — trackOpen() still runs once per presentation, so the tracked paywall_open, didPresentPaywall and storage.trackPaywallOpen() are not repeated.
  • Tests — six new PaywallMessageHandlerTests cover open before and after ready, a reload while open, while hidden and after close, and a page dying before it is ready.

I traced both reported paths (killed while presented → reload(); killed while preloaded → didFailToLoad → loadWebView() in viewWillAppear → trackOpen()). Both now deliver paywall_open exactly once to the new page, and a hidden preload is never told it is open.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using claude-opus-5.5 | 𝕏

@chroxify
chroxify force-pushed the christo/paywall-open-after-reload branch from 169c8a5 to f72f2bc Compare October 9, 2026 13:46
@maple-review-bot

maple-review-bot Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

Maple review

Nothing to review

Only CHANGELOG.md changed since the previous review. No new source or test changes require review.

f72f2bc · Updated on every push. Reply "won't fix" to dismiss a finding, or mention @maple-review-bot to ask about one.

@maple-review-bot

maple-review-bot Bot commented Oct 12, 2026 •

Copy link
Copy Markdown

Maple review

🟢 Confidence 9/10 · safe to merge
Document reload handling is covered by six focused tests, and the merge preserves the previously reviewed state transitions.
quality 100/100 · no findings · tests covered · risk medium · 1/1 new units observable

Re-arms paywall_open for SDK-initiated document reloads without repeating presentation analytics. The merge preserves the previously reviewed readiness logic; no new defects were found in the changed review scope.

What was checked
  • Compared readiness handling against f72f2bc; the merge preserves open/close queue logic.
  • Verified initial loads, refreshes, and process termination reset document readiness.
  • Read six tests covering ready, queued, hidden, closed, and repeated-load cases.
Observability coverage: 1 of 1 changes observable
Change Kind Observable Evidence
Paywall document reload recovery Existing WebView lifecycle handling yes Existing Logger messages and PaywallWebviewLoad start, complete, and processTerminated events cover recovery in the repository's analytics convention.

bda7448 · Updated on every push. Reply "won't fix" to dismiss a finding, or mention @maple-review-bot to ask about one.

paywall_open was queued when the SDK started a load, so a page that
reloaded itself (location.reload, the dev server's live reload) never got
it, and a setup still finishing for the old page could hand the new
page's paywall_open to a page that wasn't ready.

A page is now ready once its own templates are in, and it is told the
paywall is open at that point if it is. A setup for a page that has
since been replaced is ignored. The queue this replaces was the only use
of Queue, so that goes too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@maple-review-bot

maple-review-bot Bot commented Oct 12, 2026 •

Copy link
Copy Markdown

Maple review

🟢 Confidence 8/10 · likely safe to merge
Reload transitions have regression tests, but the straight-through test opens before asynchronous setup finishes, leaving the already-ready branch untested.
quality 100/100 · no findings · tests partial · risk medium

PaywallMessageHandler restores paywall_open after self-reloads and prevents replaced documents from marking the current page ready. The follow-up changes are safe to merge; analytics remain tied to presentation rather than page readiness.

  • documentId invalidates readiness from replaced pages.
  • isPaywallOpen replaces the pending-message queue.
What was checked
  • trackOpen remains the sole presentation analytics path; page setup only sends JavaScript messages.
  • Removed Queue has no remaining code references.
  • Regression tests cover self-reloads, hidden reloads, close state, and replacement during setup.

84043a9 · Updated on every push. Reply "won't fix" to dismiss a finding, or mention @maple-review-bot to ask about one.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found. The open-state re-arm now follows the page that pings, so the gaps from the previous review are closed.

Reviewed changes

Reviewed 84043a9, which moves delivery of paywall_open from SDK-started loads to each page's own setup.

  • paywall_open sent from each page's setup — didLoadWebView sets isDocumentReady and, if isPaywallOpen, passes paywall_open after the templates. A page that reloads itself (dev-server live reload, location.reload()) now gets it without a documentWillLoad() call.
  • Stale setups ignored through documentId — both onReady and documentWillLoad() bump the counter. A setup whose id is out of date returns before it marks the page ready, so a late setup from the old document can no longer deliver into the new one.
  • Queue removed — messageQueue, EnqueuedMessage and the now-unused Queue.swift (plus its pbxproj entries) are gone. .paywallOpen/.paywallClose update isPaywallOpen and only reach the page once it is ready.
  • Tests — added a self-reload case (sent twice) and a page replaced during its setup (sent once).

I traced the three gaps from the earlier thread: self-reload, a stale drain after documentWillLoad(), and the old page pinging between load() and commit. The new page gets exactly one paywall_open in all three. Everything runs on the main actor, and the paywall_open evaluation is queued behind the templates script, so ordering holds.

Pullfrog  | View workflow run | Using claude-opus-5.5 | 𝕏

…uest

The straight-through test opened the paywall before the page's setup had
run, so it never reached the path that sends paywall_open straight to a
ready page. It now waits for the setup first.

The Customer Center test checked for the catalogue request as soon as
load() returned, but that request runs in the background, so it failed
now and then on CI (and on develop). It now waits for the request.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@maple-review-bot

maple-review-bot Bot commented Oct 12, 2026 •

Copy link
Copy Markdown

Maple review

🟢 Confidence 10/10 · safe to merge
Changes since the previous review only synchronize existing tests; both wait conditions match the implementation's ordering.
quality 100/100 · no findings · tests covered · risk low

The follow-up makes the ready-page test wait for setup and moves the catalogue assertion after the background request starts. These test-only changes are safe to merge.

What was checked
  • The viewport setup marker precedes isDocumentReady in the same synchronous setup block.
  • catalogueRequested is assigned before the mock sets isHoldingCatalogue.

5479093 · Updated on every push. Reply "won't fix" to dismiss a finding, or mention @maple-review-bot to ask about one.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found. The new commit only changes tests, and both changes remove a timing dependency.

Reviewed changes

Reviewed 5479093. It only touches tests.

  • Straight-through test now opens a ready page. waitForSetup(of:) polls for the user-scalable=no script. didLoadWebView evaluates that script in the same MainActor.run block that sets isDocumentReady = true, so once the test sees it, the page is ready. The test now asserts that nothing sent paywall_open before .paywallOpen. That means it covers the direct pass-through path. Before this change, the ready path could also have satisfied it.
  • Customer center catalogue assertion moved after the wait. catalogueRequested is now checked after waitUntil { productsMock.isHoldingCatalogue }. ProductsProviderMock sets catalogueRequested before it parks on the gate, so the assertion no longer races the background fetch.

Pullfrog  | View workflow run | Using claude-opus-5.5 | 𝕏

The refresh button reset readiness before reloading. If the page's setup
was still running and the reload failed, WebKit kept the old page, but it
stayed marked not ready, so paywall_open never reached it. Reset when the
new page commits instead, so a failed load leaves the old page as it was.

The reset on web process termination is dropped: the reload that follows
commits a new page, which now does the reset.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@maple-review-bot

maple-review-bot Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

Maple review

🟢 Confidence 9/10 · safe to merge
The readiness reset is localized, and the new test exercises the commit-to-ping interval with the actual navigation callback.
quality 100/100 · no findings · tests covered · risk medium

The change resets document readiness when navigation commits, preserving the old page's readiness during provisional reloads. The new regression test covers an open arriving between commit and the replacement page's ping; no new defects were found in the changes since the previous review.

What was checked
  • Compared 5479093 with the head to isolate changes since the previous review.
  • Verified didCommit invalidates readiness through the same handler exercised by the new test.
  • Checked refresh and process-termination reloads still reach the navigation delegate.

c5305f7 · Updated on every push. Reply "won't fix" to dismiss a finding, or mention @maple-review-bot to ask about one.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found. A page is now marked not ready only when a new page commits, so a load that never commits no longer silences the old page, which is still live.

Reviewed changes

Reviewed c5305f7. It moves where the SDK marks the page not ready.

  • documentWillLoad() now runs from webView(_:didCommit:). That callback fires only for main-frame commits, so it covers the reload after the process dies, the refresh button, the initial load and in-load redirects. It skips navigations that decidePolicyFor cancels and loads that fail before they commit.
  • Removed the calls in reloadWebView() and webViewWebContentProcessDidTerminate. The commit callback covers both. loadWebView() still calls documentWillLoad() itself, which keeps the hidden-preload path working: a page killed while not active doesn't reload, and it gets marked not ready at presentation.
  • Added a commit test. paywallOpenWaitsForAPageThatReplacedTheOldOne calls didCommit on FakeWebView directly. It asserts that paywall_open waits for the new page's ping and is then sent once.

I traced the process-termination path. Between reload() and the commit, isDocumentReady stays true, so a paywall_open or paywall_close sent in that window goes to the dead page. That's harmless: isPaywallOpen is still updated, and the new page's ping sends paywall_open again.

Pullfrog  | View workflow run | Using claude-opus-5.5 | 𝕏

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@maple-review-bot

maple-review-bot Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

Maple review

Nothing to review

Only CHANGELOG.md changed since the previous review; no new source or test changes require review, and no earlier findings remain open.

b4ff0de · Updated on every push. Reply "won't fix" to dismiss a finding, or mention @maple-review-bot to ask about one.

@yusuftor
yusuftor merged commit 6f36679 into develop Oct 13, 2026
2 checks passed
@yusuftor
yusuftor deleted the christo/paywall-open-after-reload branch October 13, 2026 13:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants